跳到主要内容

编程中的 “协程” 思想

什么是协程?

这里转自 Node.js 真的有协程吗? - 陈厚来的回答 - 知乎

协程和进程、线程的区别就是两点 -- 是否隔离变量,是否自动切换运行上下文。满足这两点区别的,就是协程。

  • 进程:变量隔离,自动切换运行上下文
  • 线程:不变量隔离,自动切换运行上下文切换
  • 协程:不变量隔离,不自动切换运行上下文切换

这个回答的这句话很赞啊:在不隔离变量的情况下,自动切换运行上下文,简直就是 bug 之源,这两件事根本就不该同时支持。以后线程这个东西,应该被扫进历史的垃圾堆,线程带来的便利,比起它挖出的坑相比,不足一提。

进程和协程足矣。

协程的分类

协程可以按照以下 3 个角度进行分类

  • 对称/不对称:前者提供让出执行权的原语,后者提供唤起协程和把执行权转交给调用者的原语
  • 第一类/受限:协程是不是第一类对象
  • 有栈/无栈:协程能在调用栈的任意处挂起,还是只能在协程的函数体内挂起

这里看第三个分类的区别:

go lua ruby 采取了前者,python(asyncio) kotlin rust 以及 javascript 选择了后者

由于 JVM 的闭包是只读的,所以 kotlin 选择了使用对象存放上下文。因此在每个 suspend(kotlin 里对 async 的叫法)函数中,都会生成一个 continuation 对象,它的 label 属性存放恢复点,result 属性存放调用结果,其他属性存放 this 对象,参数和局部变量。

在调用异步方法时,额外传入 continuation,如果需要挂起,则结束当前函数,需要恢复时则调用 continuation 的 invokeSuspend 方法,它会用更新过的 continuation 调用原方法,原方法的局部变量从 continuation 中恢复;

否则把 label 值加上 1,进入下一个状态。

补充:Unity 中的协程也是无栈协程,它会在每一帧都调用一次 next

协程的上下文切换

如今协程已经成为大多数语言的标配,例如 Golang 里的 goroutine,JavaScript 里的 async/await。尽管名称可能不同,但它们都可以被划分为两大类,一类是有栈(stackful)协程,例如 goroutine;一类是无栈(stackless)协程,例如 async/await。

此处「有栈」和「无栈」的含义不是指协程在运行时是否需要栈,对于大多数语言来说,一个函数调用另一个函数,总是存在调用栈的;

而是指协程是否可以在其任意嵌套函数中被挂起,此处的嵌套函数可以理解为子函数、匿名函数等。显然有栈协程是可以的,而无栈协程则不可以。

它们的主要区别是:有栈协程能在调用栈的任意处挂起,无栈协程只能在协程的函数体内挂起

有栈协程

很多地方又把协程称为 subroutine,subroutine 是什么,就是函数。上古时期的计算机科学家们早就给出了概念,coroutine 就是可以中断并恢复执行的 subroutine,从这个角度来看协程拥有调用栈并不是一个奇怪的事情。

我们再来思考 coroutine 与 subroutinue 相比有什么区别,你会发现区别仅有一个,就是 coroutinue 可以中断并恢复,对应的操作就是 yield/resume,这样看来 subroutinue 不过是 coroutinue 的一个子集罢了。

也就是说把协程当做一个特殊的函数调用,有栈协程就是我们理想中协程该有的模样。

既然把其当做一个特殊的函数调用,对我们来说最严峻的挑战就是如何像切换函数一样去切换协程,难点在于除了像函数一样切换出去,还要在某种条件满足的时候切换回来,我们的做法可以是 在协程内部存储自身的上下文,并在需要切换的时候把上下文切换就可以了,我们知道上下文其实本质上就是寄存器,所以保存上下文实际上就是把寄存器的值保存下来,有两种方法,一种是使用汇编,libco 就使用了这种方法。还有一种是使用 ucontext.h,这个封装好的库也可以帮我们完成相关工作。

Go 有栈协程

Go 语言的出现提供了一种新的思路。

Go 语言的协程则相当于提供了一种很低成本的类似于多线程的执行体。

在Go 语言中,协程的实现与操作系统多线程非常相似。操作系统一般使用抢占的方式来调度系统中的多线程(即自动分配调度),而 Go 语言中,依托于操作系统的多线程,在运行时刻库中实现了一个协作式的调度器。

这里的调度真正实现了上下文的切换,简单地说,Go 系统调用执行时,调度器可能会保存当前执行协程的上下文到堆栈中。然后将当前协程设置为睡眠,转而执行其他的协程。

这里需要注意,所谓的 Go 系统调用并不是真正的操作系统的系统调用,而是 Go 运行时刻库提供的对底层操作系统调用的一个封装。

举例说明:Socket recv。我们知道这是一个系统调用,Go 的运行时刻库也提供了几乎一模一样的调用方式,但这只是建立在 epoll 之上的模拟层,底层的 socket 是工作在非阻塞的方式,而模拟层提供给我们了看上去是阻塞模式的 socket。读写这个模拟的 socket 会进入调度器,最终导致协程切换。

目前 Go 调度器实现在用户空间,本质上是一种协作式的调度器。这也是为什么如果写了一个死循环在协程里,则协程永远没有机会被换出,一个 Processor 相当于就被浪费掉了。

有栈的协程和操作系统多线程是很相似的。考虑以下伪代码:

func routine() int
{
var a = 5
sleep(1000)
a += 1
return a
}

sleep 调用时,会发生上下文的切换,当前的执行体被挂起,直到约定的时间再被唤醒。

局部变量 a 在切换时会被保存在栈中,切换回来后从栈中恢复,从而得以继续运行。

所谓有栈就是指执行体本身的栈。每次创建一个协程,需要为它分配栈空间。究竟分配多大的栈的空间是一个技术活。分的多了,浪费,分的少了,可能会溢出。Go在这里实现了一个协程栈扩容的机制,相对比较优雅的解决了这个问题。

另外一个问题,关于上下文切换,这一般是跟平台或者CPU相关的代码,因为要涉及到寄存器操作。同时上下文切换也是有一点代价的,因为毕竟需要额外执行一些指令(这一点可以忽略掉,无栈的协程实现也需要一些额外的指令来完成程序逻辑的跳转)。

无栈协程

相比于有栈协程直接切换栈帧的思路,无栈协程在不改变函数调用栈的情况下,采用类似生成器(generator)的思路实现了上下文切换

那么所谓的无栈协程是什么呢?其实无栈协程的本质就是一个状态机(state machine),它可以理解为在另一个角度去看问题,即同一协程的切换本质不过是指令指针寄存器的改变。

即:将由 yield 切割的代码组成一个状态机(其实就是迭代器),切换状态 next() 就是执行下一块,如下:

function* anotherGenerator(i) {
yield i + 1;
yield i + 2;
yield i + 3;
}

Rust 的无栈协程

无栈协程顾名思义就是不使用栈和上下文切换来执行异步代码逻辑的机制。这里异步代码虽然是异步的,但执行起来看起来是一个同步的过程。

从这一点上来看 Rust 协程与 Go 协程也没什么两样。举例说明:

async fn routine() 
{
let mut a = 5;
sleep(1000).await;
a = a + 1;
a
}

几乎是一样的流程。Sleep 会导致睡眠,当时间已到,重新返回执行,局部变量 a 内容应该还是5。

Go协程是有栈的,所以这个局部变量保存在栈中,而 Rust 是怎么实现的呢?答案就是 Generator 生成的状态机。

Generator 和闭包类似,能够捕获变量 a,放入一个匿名的结构中,在代码中看起来是局部变量的数据 a,会被放入结构,保存在全局(线程)栈中。

另外值得一提的是,Generator 生成了一个状态机以保证代码正确的流程。从 sleep.await 返回之后会执行 a= a + 1 这行代码。async routine() 会根据内部的 .await 调用生成这样的状态机,驱动代码按照既定的流程去执行。

按照一般的说法。无栈协程有很多好处。首先不用分配栈。因为究竟给协程分配多大的栈是个大问题。特别是在32位的系统下,地址空间是有限的。每个协程都需要专门的栈,很明显会影响到可以创建的协程总数。

其次,没有上下文切换,貌似性能也许会好一些?当然,更大的好处是并不需要与 CPU 体系相关代码,也就有了更好的跨平台的能力。当然,无栈问题也不少。例如,Rust 著名的 PIN 问题。

总结

把栈指针寄存器换成 this 指针,这叫做无栈协程,再配合点语法糖,让访问对象成员如同访问局部变量一样(主流编译语言都支持省略 this 直接访问成员)

实现原理:

1、有栈协程:用 (e)rsp栈寄存器来索引局部变量,上下文是协程私有的栈。 访问上下文数据也就是局部变量的时候,我们无需显式的使用栈寄存器 + 偏移量来访问,而是直接访问变量名。

(有栈)协程可以理解为一个用户态下的线程,在用户态下进行线程(协程)的上下文切换。但是和传统的线程不同的是:线程是抢占式执行,当发生系统调用或者中断的时候,交由 OS 调度执行;而协程是通过 yield 主动让出 cpu 所有权,切换到其他协程执行。

2、无栈协程:用 this 来索引对象的成员变量,上下文就是对象自己(即把上下文对象保存在自己身上)。访问上下文数据也就是成员变量的时候,我们无需显式的使用 this + 成员偏移量(或者变量名)来访问,而是直接访问变量名。

而无栈协程本质就是一种 “迭代器模式”

两种协程访问的上下文中的数据,生命周期都大于函数的返回:

  • 有栈协程:栈的生命周期晚于函数的返回
  • 无栈协程:this 对象的生命周期也晚于函数的返回

Reference

Node.js 真的有协程吗? - 圆珠笔的回答 - 知乎 JavaScript中的协程 - 丛来花间翁的文章 - 知乎 Node.js 真的有协程吗? - 陈厚来的回答 - 知乎 用 TypeScript 看无栈协程